上一篇文章我們提到了購物車的設計想法,以及會使用到的Angular功能。本篇文章我們從專案架構開始,逐一使用範例程式碼進行實作說明該如何在前端建立一個最基本的購物車。
將上一篇文章所提到的整個購物流程中,關於購物車的部分摘出,可發現功能大致如下。
將上述的功能進行架構設計,購物車狀態相關的內容統一使用NgRx進行管理,與購物車有關的邏輯封裝成service,頁面分成商品列表,購物車和結帳頁,最後針對畫面上需要計算金額按幣別的部分則封裝成pipe,得到的專案架構如下:
src/
├── app/
│   ├── models/
│   │   └── cart-item.model.ts         # 定義購物車項目的資料結構
│   ├── services/
│   │   └── cart.service.ts             # 管理購物車邏輯的服務
│   ├── state/
│   │   ├── cart.actions.ts             # 定義購物車的動作
│   │   ├── cart.reducer.ts             # 處理購物車的狀態變更
│   │   ├── cart.selectors.ts           # 定義購物車的選擇器
│   │   └── cart.state.ts               # 定義購物車的狀態結構
│   ├── components/
│   │   ├── product-list/
│   │   │   ├── product-list.component.ts         # 顯示可供購買的商品
│   │   │   └── product-list.component.html      # 產品列表的模板
│   │   ├── cart/
│   │   │   ├── cart.component.ts                 # 顯示購物車內容
│   │   │   └── cart.component.html               # 購物車的模板
│   │   └── checkout/
│   │       ├── checkout.component.ts             # 處理結帳邏輯
│   │       └── checkout.component.html           # 結帳的模板
│   ├── app.component.ts                         # 應用的根元件
│   └── app.config.ts                            # 應用的配置,包括路由和 Store
src/app/models/cart-item.model.ts
定義購物車項目的資料結構。
src/app/services/cart.service.ts
將與購物車相關的邏輯集中在這裡,包括獲取商品、更新數量、移除商品和清空購物車的功能。
src/app/state/cart.actions.ts
定義與購物車操作相關的動作,如添加商品、移除商品、更新數量等。
src/app/state/cart.reducer.ts
處理購物車狀態的變更,根據動作來更新購物車內的商品。
src/app/state/cart.selectors.ts
定義購物車的選擇器,幫助獲取購物車中的商品和計算總金額。
src/app/state/cart.state.ts
定義購物車的狀態結構,描述購物車應包含的屬性。
src/app/components/product-list/product-list.component.ts
顯示可供購買的商品列表,並使用 CartService 將商品添加到購物車。
src/app/components/product-list/product-list.component.html
產品列表的模板,顯示每個商品的名稱和價格。
src/app/components/cart/cart.component.ts
顯示購物車中的商品,使用 CartService 進行商品的管理。
src/app/components/cart/cart.component.html
購物車的模板,顯示購物車內的商品、數量及總金額。
src/app/components/checkout/checkout.component.ts
處理結帳邏輯,包括用戶的地址和支付資訊的收集。
src/app/components/checkout/checkout.component.html
結帳的模板,包含結帳所需的表單元素。
src/app/app.component.ts
應用的根元件,提供應用的基本結構,包含路由的 outlet。
src/app/app.config.ts
應用的配置,定義路由和 Store 模組,為整個應用提供配置。
讓我們開始吧,首先我們從購物車狀態下手。
第一步先建立 cart-item.model.ts ,以購物車內的商品來說,主要需要的資訊可能會有,商品id,品名,價格,購買數量。
export interface CartItem {
	id: number;
	name: string;
	price: number;
	quantity: number;
}
接著是NgRx的部分,前面有提到,購物車的加入商品,刪除商品,更新數量這幾種操作,也代表我們需要實作對應的action。
於是我們建立 cart.actions.ts 。
import { createAction, props } from '@ngrx/store';
import { CartItem } from '../models/cart-item.model';
export const addToCart = createAction(
  '[Cart] Add to Cart',
  props<{ item: CartItem }>()
);
export const removeFromCart = createAction(
  '[Cart] Remove from Cart',
  props<{ itemId: number }>()
);
export const updateQuantity = createAction(
  '[Cart] Update Quantity',
  props<{ itemId: number; quantity: number }>()
);
export const clearCart = createAction('[Cart] Clear Cart');
有了action之後,就是重頭戲的購物車狀態了,建立 cart.state.ts 和 cart.reducer.ts ,各action對應的邏輯實作我們也可以放在這邊。
import { CartItem } from '../models/cart-item.model';
export interface CartState {
  items: CartItem[];
}
addToCart 時,首先會檢查購物車中是否已經有相同的商品(根據商品 ID)。如果有,則將該商品的數量增加;如果沒有,則將新商品加入購物車中。
removeFromCart 時,會根據提供的商品 ID 從購物車中移除該商品。這是通過過濾 items 陣列來實現的。
updateQuantity 時,會查找對應 ID 的商品並更新其數量。如果找到該商品,則更新其 quantity 屬性。clearCart 時,會清空購物車中的所有商品。這是通過將 items 設置為一個空陣列來實現的。
import { createReducer, on } from '@ngrx/store';
import { CartState } from './cart.state';
import { addToCart, removeFromCart, updateQuantity, clearCart } from './cart.actions';
export const initialState: CartState = {
  items: []
};
export const cartReducer = createReducer(
  initialState,
  on(addToCart, (state, { item }) => {
    const existingItem = state.items.find(i => i.id === item.id);
    if (existingItem) {
      existingItem.quantity += item.quantity;
      return { ...state };
    }
    return { ...state, items: [...state.items, item] };
  }),
  on(removeFromCart, (state, { itemId }) => ({
    ...state,
    items: state.items.filter(item => item.id !== itemId)
  })),
  on(updateQuantity, (state, { itemId, quantity }) => {
    const item = state.items.find(i => i.id === itemId);
    if (item) {
      item.quantity = quantity;
    }
    return { ...state };
  }),
  on(clearCart, (state) => ({
    ...state,
    items: []
  }))
);
當外部需要取得購物車狀態的時候,有的人可能只需要金額,有的可能只需要商品數量。所以我們要實作 cart.selectors.ts 將選取項目的邏輯封裝在其中,方便複用。
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { CartState } from './cart.state';
const selectCartState = createFeatureSelector<CartState>('cart');
export const selectCartItems = createSelector(
  selectCartState,
  (state: CartState) => state.items
);
export const selectCartTotal = createSelector(
  selectCartItems,
  (items) => items.reduce((total, item) => total + (item.price * item.quantity), 0)
);
最後如上面所提到,有很多人需要跟購物車狀態進行互動,所以我們可以將與購物車有關的邏輯和行為透過service進行統一的管理,方便維護和延展。於是我們要實作 cart.service.ts ,如此一來,未來無論是需要新增購物車的功能,盤點既有購物車的業務等,都可以透過這支service來快速理解。
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { CartItem } from '../models/cart-item.model';
import { removeFromCart, updateQuantity, clearCart } from '../state/cart.actions';
import { selectCartItems, selectCartTotal } from '../state/cart.selectors';
@Injectable({
  providedIn: 'root'
})
export class CartService {
  constructor(private store: Store) {}
  getCartItems(): Observable<CartItem[]> {
    return this.store.select(selectCartItems);
  }
  getCartTotal(): Observable<number> {
    return this.store.select(selectCartTotal);
  }
  updateItemQuantity(itemId: number, quantity: number) {
    this.store.dispatch(updateQuantity({ itemId, quantity }));
  }
  removeItem(itemId: number) {
    this.store.dispatch(removeFromCart({ itemId }));
  }
  clearCart() {
    this.store.dispatch(clearCart());
  }
}
到這裡,購物車的狀態就已經實作完畢了,接著我們就可以來實作那些與購物車進行互動的頁面了。
首先,消費者會透過商品列表來加入商品,但商品列表本身不是本次重點,所以我們只列出與購物車有關的功能,商品資料會先用假資料代替。 product-list.component.ts  和  product-list.component.html.ts 的實作如下。
import { Component } from '@angular/core';
import { CartService } from '../../services/cart.service';
import { CartItem } from '../../models/cart-item.model';
@Component({
  selector: 'app-product-list',
  standalone: true,
  templateUrl: './product-list.component.html',
  styleUrls: ['./product-list.component.css']
})
export class ProductListComponent {
  products: CartItem[] = [
    { id: 1, name: '商品 A', price: 100, quantity: 1 },
    { id: 2, name: '商品 B', price: 200, quantity: 1 },
    { id: 3, name: '商品 C', price: 300, quantity: 1 },
  ];
  constructor(private cartService: CartService) {}
  addToCart(product: CartItem) {
    this.cartService.updateItemQuantity(product.id, product.quantity);
  }
}
<h1>商品列表</h1>
<div *ngFor="let product of products">
  <span>{{ product.name }} - {{ product.price | currency }}</span>
  <button (click)="addToCart(product)">加入購物車</button>
</div>
可以看到在封裝了service後,將商品加入購物車這件事情變得多單純容易,並且在盤點或是維護的時候,只需要看CartService的引用即可快入掌握範圍。
加入購物車後我們可以前往購物車進行查看。 cart.component.ts 和 cart.component.html 的實作如下。
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { CartService } from '../../services/cart.service';
import { CartItem } from '../../models/cart-item.model';
@Component({
  selector: 'app-cart',
  standalone: true,
  templateUrl: './cart.component.html',
  styleUrls: ['./cart.component.css']
})
export class CartComponent {
  items$: Observable<CartItem[]>;
  total$: Observable<number>;
  constructor(private cartService: CartService) {
    this.items$ = this.cartService.getCartItems();
    this.total$ = this.cartService.getCartTotal();
  }
  increaseQuantity(item: CartItem) {
    this.cartService.updateItemQuantity(item.id, item.quantity + 1);
  }
  decreaseQuantity(item: CartItem) {
    if (item.quantity > 1) {
      this.cartService.updateItemQuantity(item.id, item.quantity - 1);
    }
  }
  removeItem(itemId: number) {
    this.cartService.removeItem(itemId);
  }
  proceedToCheckout() {
    this.items$.subscribe(items => {
      console.log('Proceeding to checkout with items:', items);
    });
  }
}
<div class="cart">
  <h2>購物車</h2>
  <div *ngFor="let item of items$ | async" class="cart-item">
    <span>{{ item.name }}</span>
    <span>價格: {{ item.price | currency }}</span>
    <div>
      <button (click)="decreaseQuantity(item)">-</button>
      <span>{{ item.quantity }}</span>
      <button (click)="increaseQuantity(item)">+</button>
      <button (click)="removeItem(item.id)">刪除</button>
    </div>
  </div>
  <h3>總金額: {{ (total$ | async) | currency }}</h3>
  <button (click)="proceedToCheckout()">前往結帳</button>
</div>
因為我們都已經把邏輯進行封裝了,所以可以發現Component主要負責的內容就是取得使用者需要在畫面上看到的資料以及UX操作的實作。這樣可以讓程式碼各司其職,避免業務邏輯和UX操作高度偶合,閱讀程式碼的時候也會輕鬆很多。
到這邊我們基礎的購物車實作就告一段落了,本篇重點主要在NgRx的應用還有封裝Service的好處。不過真實情境的購物車並沒有這麼單純。可以發現,會員,金流和後端等角色在這邊是看不到的。
下一篇文章我們就來討論,當這些外部因素加入了之後,有哪些難題需要克服。